route.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import { NextRequest, NextResponse } from 'next/server';
  2. import { LoginResponse } from '@/types/response/auth';
  3. import { fetchJson } from '@/lib/utils/server';
  4. import { isSocialLoginProvider, SOCIAL_PROVIDER_LABELS, OAUTH_STATE_COOKIE_PREFIX } from '@/constants/social';
  5. /**
  6. * 소셜 로그인 콜백 라우트 (Naver/Kakao) — /login/{provider}/callback
  7. *
  8. * 1. ?code&state(&error) 수신 → start 라우트가 심은 httpOnly state 쿠키와 대조 (CSRF 방어)
  9. * 2. 백엔드 POST /api/auth/social-login (code 교환 + userinfo + 계정 매칭)
  10. * 3. 성공: accessToken/refreshToken httpOnly 쿠키(기존 google/callback 옵션 동일) → /login/{provider}/complete
  11. * 4. Auth.EmailRequired / Auth.AccountExists: 안내 화면(/login/social-notice)으로 분기
  12. * 5. 기타 실패: /login?error={message} (로그인 페이지 alert 관례)
  13. */
  14. export async function GET(request: NextRequest, { params }: { params: Promise<{ provider: string }> })
  15. {
  16. const { provider } = await params;
  17. if (!isSocialLoginProvider(provider)) {
  18. const url = new URL('/login', request.url);
  19. url.searchParams.set('error', '지원하지 않는 소셜 로그인입니다.');
  20. return NextResponse.redirect(url);
  21. }
  22. const label = SOCIAL_PROVIDER_LABELS[provider];
  23. const stateCookieName = `${OAUTH_STATE_COOKIE_PREFIX}${provider}`;
  24. const savedState = request.cookies.get(stateCookieName)?.value ?? null;
  25. // state 쿠키는 일회용 — 성공/실패와 무관하게 응답에서 제거
  26. const finish = (response: NextResponse) => {
  27. response.cookies.delete(stateCookieName);
  28. return response;
  29. };
  30. const failRedirect = (message: string) => {
  31. const url = new URL('/login', request.url);
  32. url.searchParams.set('error', message);
  33. return finish(NextResponse.redirect(url));
  34. };
  35. const noticeRedirect = (reason: 'email-required'|'account-exists') => {
  36. const url = new URL('/login/social-notice', request.url);
  37. url.searchParams.set('reason', reason);
  38. url.searchParams.set('provider', provider);
  39. return finish(NextResponse.redirect(url));
  40. };
  41. const searchParams = request.nextUrl.searchParams;
  42. const code = searchParams.get('code');
  43. const state = searchParams.get('state');
  44. const oauthError = searchParams.get('error');
  45. const oauthErrorDescription = searchParams.get('error_description');
  46. // 사용자가 동의 화면에서 취소한 경우 등 provider 측 에러
  47. if (oauthError) {
  48. if (oauthError === 'access_denied') {
  49. return failRedirect(`${label} 로그인이 취소되었습니다.`);
  50. }
  51. return failRedirect(oauthErrorDescription || `${label} 인증에 실패했습니다. 다시 시도해주세요.`);
  52. }
  53. if (!code) {
  54. return failRedirect(`${label} 인증 코드가 없습니다. 다시 시도해주세요.`);
  55. }
  56. if (!state || !savedState || state !== savedState) {
  57. return failRedirect('로그인 요청 검증(state)에 실패했습니다. 다시 시도해주세요.');
  58. }
  59. try {
  60. const redirectUri = `${request.nextUrl.origin}/login/${provider}/callback`;
  61. const res = await fetchJson<LoginResponse>('/api/auth/social-login', {
  62. method: 'POST',
  63. body: JSON.stringify({
  64. Provider: provider,
  65. Code: code,
  66. State: state,
  67. RedirectUri: redirectUri
  68. })
  69. });
  70. if (res.success && res.data) {
  71. const response = NextResponse.redirect(new URL(`/login/${provider}/complete`, request.url));
  72. const cookieOptions = { httpOnly: true, path: '/' };
  73. response.cookies.set('accessToken', res.data.accessToken, cookieOptions);
  74. response.cookies.set('refreshToken', res.data.refreshToken, cookieOptions);
  75. return finish(response);
  76. }
  77. const errorCode = res.errors?.[0]?.code;
  78. // 카카오 이메일 선택동의 미동의 → 이메일 제공 동의 안내 화면
  79. if (errorCode === 'Auth.EmailRequired') {
  80. return noticeRedirect('email-required');
  81. }
  82. // 같은 이메일의 기존 계정 존재 (자동연결 금지) → 기존 계정 로그인 + SNS 연동 안내 화면
  83. if (errorCode === 'Auth.AccountExists') {
  84. return noticeRedirect('account-exists');
  85. }
  86. if (errorCode === 'Auth.SocialNotConfigured') {
  87. return failRedirect(`${label} 로그인이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.`);
  88. }
  89. return failRedirect(res.errors?.[0]?.description || res.message || `${label} 로그인에 실패했습니다. 다시 시도해주세요.`);
  90. } catch (e) {
  91. console.error(`[${provider} Callback] Error:`, e);
  92. return failRedirect(e instanceof Error ? e.message : `${label} 로그인 처리 중 오류가 발생했습니다.`);
  93. }
  94. }